Skip to content

feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer) - #267

Draft
amal66 wants to merge 8 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/organizations-rbac
Draft

feat: multi-tenant organizations with a project role ladder (owner/manager/editor/viewer)#267
amal66 wants to merge 8 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/organizations-rbac

Conversation

@amal66

@amal66 amal66 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Followed by #268

Design (ADR)

Context. The app has been strictly per-user since the baseline: every data table carries a user_id anchor and access is "row owner OR email in shared_with". A law firm is not one user — firms need a tenant boundary, roles inside it, and content that is visible to colleagues without emailing a share for every row. The fork (amal66/mike) built this as a full feature on its main branch; this PR ports it onto the upstream layout — and gives it the permission model the first revision only promised (see "Permissions model" below).

Decision. Introduce a tenant layer without disturbing the per-user anchor:

  • organizations — a tenant. personal = true marks the auto-provisioned one-per-user org every account gets (signup trigger + backfill), so single-user usage is completely unchanged: content simply lands in the caller's personal org.
  • org_members(org_id, user_id, role) with role in ('owner','admin','member'). This is the RBAC edge: owner/admin manage the org, its members and teams (only an owner can grant the owner role); member gets read access to org content. Last-owner protection prevents demoting/removing the sole owner.
  • teams / team_members — structural intra-org grouping (membership + naming); finer team-scoped permissions are a deliberate future extension point.
  • org_id on projects / documents / workflows / tabular_reviews as a nullable FK with ON DELETE SET NULLuser_id remains the hard CASCADE anchor, so account deletion works exactly as before and dropping an org never orphan-deletes user rows.
  • Access stays three branches evaluated in precedence — (1) row owner, (2) shared_with email, (3) org membership — but each branch now derives a project role (owner / manager / editor / viewer), and routes gate on a single capability matrix instead of ad-hoc isOwner checks. isOwner keeps meaning "row owner"; canManage is now derived from the matrix. The four overview RPCs gain the same third branch in SQL so list views and detail endpoints can never disagree.
  • New tables ship with RLS enabled + anon/authenticated revoked (default-deny for direct clients; the API runs with the service key and enforces access in code, matching the existing posture).

Consequences. Existing installs are migrated in place: the backfill gives every user a personal org and stamps their existing rows, so nothing becomes invisible when the org branch lands. Org membership grants visibility, not ownership — and unlike the first revision of this PR, that promise is now enforced, not just documented: plain org members are read-only, and destructive/structural operations require manager+ (see the matrix). Account deletion tears down the user's org footprint (personal org dropped; sole-ownership of shared orgs handed off to the earliest remaining member; empty orgs removed), and the GDPR export includes the user's orgs/teams/memberships.

Alternatives considered. (a) Overloading shared_with with group emails — no roles, no tenant boundary, O(members) writes per row. (b) Making org_id NOT NULL with CASCADE — breaks system workflows (null user_id) and turns org deletion into content deletion. (c) SSO/SCIM-first provisioning — intentionally out of scope; organizations is shaped to grow sso_config/scim_token columns and an org_invitations table, and the role CHECK can gain roles without a table rewrite. (d) Per-folder/per-document ACL overrides (full Google Drive "My Drive" semantics) — rejected for now: permissions attach at the container root and inherit, like Drive shared drives and legal-matter workspaces; per-item overrides add a lot of model complexity for little demand at this scale.

Permissions model

Every access branch resolves to one project role, and every route declares the capability it needs via can(role, capability) (backend/src/lib/permissions.ts — the whole policy is one table, exhaustively unit-tested). This generalises #193 (owner-only folder delete) from a one-route fix into the policy itself; the same missing-gate class existed on ~ten other destructive/structural routes, all closed here.

Capability viewer (org member) editor (shared_with) manager (org owner/admin) owner (row owner)
View/download docs, read chats & reviews, watch generation streams
Upload docs, push versions, chat, accept/reject edits, run/generate reviews
Rename/move documents, create folders
Rename/move/delete folders, edit review title/columns/document set, clear cells
Edit sharing & project metadata
Delete the project/review itself

The editor/manager line is the load-bearing one (Drive's writer vs. fileOrganizer): content collaboration stays broad, structural/destructive power is narrow. Deleting containers stays owner-only so org admins can curate without being able to erase a colleague's project.

Behaviour changes vs. upstream main, all disclosed:

  • Tightened: shared (shared_with) collaborators can no longer rename/move/delete folders (deletion was already owner-gated by Restrict project folder deletion to owners #193; rename/move had no gate), edit a review's title/column set/document set (removing a document deletes its cells; for title/document set this matches Require review owner for tabular settings edits #175 exactly, generalised to the org tier), or clear extracted cells. They keep full content collaboration (uploads, versions, chat, generation, doc rename/move).
  • Tightened: plain org members are read-only on org content, per the ADR. (In the first revision of this PR they would have inherited every shared-member power, including the destructive ones above.)
  • Widened: org owners/admins can now manage projects in their org — metadata, sharing, folder structure, review structure — without owning the row (PATCH /projects/:id drops its user_id filter in favour of the manager gate). Container deletion is not widened.
  • Fixed: GET /projects/:id/people previously 404'd for org members who could read everything else about the project; the roster now follows project.view. GET /projects/:id also routes through checkProjectAccess instead of a hand-rolled inline check.
  • Surfaced: project and review detail responses now include access_role alongside is_owner, so a client can render per-role affordances instead of re-deriving policy from one boolean.

Summary

A firm is not one user. This PR adds multi-tenant organizations with owner/admin/member roles: every account gets a personal org automatically (so nothing changes for individuals), firms can create shared orgs, add colleagues by email, group them into teams, and everyone in the org can see the org's projects, documents, workflows and tabular reviews — with a four-tier project role ladder (owner/manager/editor/viewer) and a single capability matrix deciding who can change what.

Changes

  • Migrations (backend/migrations/, upstream naming convention):
    • 20260717_01_organizations_rbac.sql — org/RBAC schema, org_id columns + indexes, signup-trigger extension, RLS + grant hardening.
    • 20260717_02_backfill_personal_orgs.sql — idempotent personal-org + membership + org_id backfill for existing data.
    • 20260717_03_org_overview_rpcs.sql — org-membership branch added to get_workflows_overview, get_chats_overview, get_projects_overview, get_tabular_reviews_overview.
    • backend/schema.sql updated to match (tables, columns, trigger, RPCs).
  • Permissions layer: backend/src/lib/permissions.tsProjectRole, Capability, can(); the role×capability policy as one data table. backend/src/lib/access.ts derives projectRole on every branch of checkProjectAccess / ensureDocAccess / ensureReviewAccess (row owner → owner, shared email → editor, org owner/admin → manager, org member → viewer).
  • Route sweep: every write route under /projects, /single-documents, /tabular-review, plus project chat and chat-in-project creation, now declares its needed capability. Read routes stay at project.view.
  • Org REST module: backend/src/routes/orgs.ts (thin handlers, {detail} error bodies) + backend/src/lib/orgs.ts (service layer enforcing the role model), mounted at /orgs in app.ts. Endpoints: org CRUD/list, member add/update/remove (by email), team CRUD + team membership.
  • Tenant stamping on create: projects (explicit org_id validated against membership, else personal org), document uploads/copies/project-assignment, tabular reviews (inherit project org), workflows (personal org). Access-check loads now select org_id.
  • Account deletion / export: deleteUserOrganizations (personal-org teardown, sole-owner handoff) wired into deleteAllUserData; orgs/teams/memberships added to the user data export.
  • Tests: permissions.test.ts (the full role×capability matrix, cell by cell, plus fail-closed on unknown roles), access.test.ts (role derivation on all four branches, cross-tenant denial), orgs.test.ts (service RBAC), userDataCleanup.orgs.test.ts (org teardown/handoff), and route-level gate coverage in the existing integration suites (folder-delete tier walk, review clear-cells/columns gates).

No frontend changes are required (everything is additive; access_role is new, is_owner unchanged). Teaching the web UI to use access_role instead of is_owner is a natural follow-up PR.

Why

Multi-tenant RBAC is the difference between "a tool a lawyer uses" and "a tool a firm can adopt": tenant isolation is enforced in one shared code path (access.ts + RPCs in lockstep), roles come with escalation guards (admins cannot mint owners), and the personal-org design means zero migration burden for existing single users. The capability matrix keeps it honest: without it, adding a colleague to your org would silently grant them destructive power over every project in it — the exact bug class #193 just fixed for shared_with, at tenant scale. No new runtime dependencies. No new always-on cost: the org branch only adds queries on the access paths that already hit the database.

Testing

Rebased on current main (post-#193/#175/#228#238), so totals include the merged vitest harness and route suites:

  • cd backend && npm ci && npx tsc --noEmit → clean.
  • cd backend && npx vitest run307 passed | 5 skipped (23 files), including 30 permission-matrix cells, role-derivation tests on all four branches, and the new route-gate cases (folder-delete allowed for owner/manager, blocked for editor/viewer; clear-cells manager gate; columns manager gate).
  • Runtime smoke: server boots and /health responds.

Provenance

The schema, migrations, org module, cleanup/export wiring and tenant stamping are mechanical ports of amal66/mike@origin/main (b3166dd) — path moves (apps/api/src/modules/orgs/*backend/src/{routes,lib}/orgs.ts), import rewrites, and re-application of the fork's org hunks onto upstream's route files. Exceptions, all mechanical adaptations to upstream's conventions:

  • Migrations renamed to upstream's YYYYMMDD_NN_name.sql convention and dated 20260717 so they sort after existing migrations (fork names: 20260701000000/1/2_*). Comment cross-references to fork-only migrations adjusted.
  • ::text casts in the backfill and RPC org clauses: upstream stores content-table user_id as text while organizations.created_by/org_members.user_id are uuid FKs (the fork migrated its ids to uuid; upstream has not). RPC bodies otherwise reproduce upstream's current definitions plus the fork's org branches verbatim; the fork's unrelated drift (result caps / lower() email normalization from other fork migrations) was NOT carried in.
  • handle_new_user extends upstream's current email-mirror version (20260703_01) with the fork's org-provisioning block (the fork's own merged version, verbatim).
  • Route-layer hunks applied to upstream's routes/*.ts instead of the fork's modules/* split; the fork's filename column on document inserts was not carried (upstream dropped documents.filename in 20260602_04).
  • lib/access.ts started from the fork's file (a direct descendant of the upstream file); one entangled fork hunk includes a defensive userEmail.toLowerCase() in listAccessibleProjectIds (a no-op upstream — requireAuth already lowercases).
  • userDataCleanup.orgs.test.ts drops the fork's vi.mock("../env") (fork-only zod env module; upstream reads process.env).
  • The permissions layer is NOT a portpermissions.ts, the projectRole derivation, the route capability sweep and their tests are new code written for this PR after review discussion, closing the gap between the ADR's stated policy ("visibility, not ownership") and what the first revision actually enforced. The fork will adopt the same model.

Credits & prior art

  • @Chris-o-O (Chris-o-O/mike-explor) — independently parallels this work: their fork built organizations and team management on top of Mike. Different implementation (this PR's personal-org anchor, RBAC roles and RPC lockstep are the fork's own design), same conviction that a firm is not one user.
  • The role ladder follows Google Drive's separation of content editing from structure management (writer vs. fileOrganizer) and matter-workspace conventions from legal AI tools: private by default, container-rooted inheritance, admin oversight without container deletion.

🤖 Generated with Claude Code

Reference: the fork-side ADR PR is amal66#38 (same branch, kept for provenance).

amal66 and others added 8 commits August 5, 2026 20:36
Introduce an organizations tenant layer on top of the existing per-user
model: every account gets an auto-provisioned personal org, orgs carry
owner/admin/member RBAC via org_members, and teams group members inside
an org. projects/documents/workflows/tabular_reviews gain a nullable
org_id (ON DELETE SET NULL) so org membership becomes a third access
branch alongside row ownership and shared_with emails — in the access
helpers, the overview RPCs, and the org-aware /orgs REST module.

Mechanical port of the organizations/RBAC feature from amal66/mike@main
(b3166dd) onto the upstream layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
…nches

Give the three access branches a Drive-style role ladder instead of raw
ok/isOwner flags: row owner → owner, shared_with email → editor, org
owner/admin → manager, plain org member → viewer. A single capability
matrix (lib/permissions.ts) maps roles to what routes may do — view,
content.edit, docs.organize, structure.manage, members.manage,
container.delete — and every project/document/review write route now
declares the capability it needs instead of hand-rolling an owner check.

This makes the ADR's 'org membership grants visibility, not ownership'
promise real: plain org members are read-only (previously the org branch
returned ok:true and most write routes gated on nothing beyond ok), and
org owner/admins can curate content (manage folders, sharing, review
structure) without being able to delete containers they don't own.

Notable tightenings, all fail-closed:
- folder rename/move/delete, doc-set/column edits on reviews, and
  clear-cells are manager+ (generalising the owner-only folder-delete
  gate that landed upstream in Open-Legal-Products#193)
- version pushes, edit resolution, chat, and review generation are
  editor+ (org viewers excluded)
- project PATCH (metadata + sharing) is manager+, so org admins can
  manage without owning; project/review DELETE stays owner-only
- GET /projects/:id and /people now go through checkProjectAccess (the
  roster previously 404'd for org members who could read the project)

Detail responses expose access_role alongside is_owner so the client
can render per-role affordances. can() is exhaustively unit-tested
(role × capability), and route suites cover the new gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Org viewers could append messages to a colleague's chat — and burn LLM
credits doing it — because the existing-chat path only asked "can you
see this chat?", never "can you write to it?".

WHY THIS MATTERS

The organizations feature gives every plain org member visibility into
their org's projects: they map to the "viewer" project role
(backend/src/lib/access.ts, orgRoleToProjectRole). Visibility is the
point — but the capability matrix (backend/src/lib/permissions.ts) is
explicit that chatting is a WRITE:

    capability    | min role | covers
    content.edit  | editor   | upload documents, push versions, CHAT, ...

Creating a chat already honored that: validateAccessibleProjectId gates
new project chats on can(projectRole, "content.edit"). But POST /chat
with an existing chat_id, and POST /chat/:chatId/generate-title, gated
only on getAccessibleChat — which returned the chat whenever
checkProjectAccess(...).ok was true. That is true for org viewers. So a
viewer could:

  - append user messages into a colleague's project chat,
  - trigger LLM generation (spending the owner's configured API keys),
  - overwrite the chat's title via generate-title (an UPDATE on chats).

WHAT IS THE ACCESS-VS-CAPABILITY DISTINCTION

"Can you see this resource?" and "can you write to this resource?" are
different questions and must be answered by different checks:

  - an ACCESS check resolves whether the caller has any standing at all
    (owner / shared editor / org member) — it yields a role;
  - a CAPABILITY check asks whether that role clears the bar for the
    specific operation: can(role, "content.edit").

A route that stops after the access check silently grants its weakest
role the powers of its strongest. That is exactly the bug class here:
read-only endpoints (GET /chat/:chatId) and write endpoints (POST
/chat) shared one gate, so the gate had to be as permissive as the
reads — and the writes inherited that permissiveness.

HOW THE FIX WORKS

getAccessibleChat now returns the caller's ProjectRole along with the
chat, instead of flattening everything to "found / not found":

    type ChatAccess =
        | { ok: true; chat: AccessibleChat; projectRole: ProjectRole }
        | { ok: false };

The chat owner maps to "owner"; for project chats everyone else
inherits their project role from checkProjectAccess. Chats without a
project_id remain reachable only by their owner (unchanged).

Each caller then declares the capability it needs:

  - GET /chat/:chatId — read: any resolved role (project.view
    semantics), so org viewers keep read access. Unchanged behavior.
  - POST /chat with chat_id — write: rejects with 403 unless
    can(projectRole, "content.edit"), mirroring the new-chat path.
  - POST /chat/:chatId/generate-title — write (UPDATEs chats.title):
    same 403 gate.

403 (not 404) is correct for viewers: they are allowed to know the chat
exists — they can read it — they just cannot modify it.

Note: routes/projectChat.ts does NOT have this hole — it already gates
the entire POST on content.edit before touching any chat row.

Tests (chat.routes.test.ts) add a table-aware supabase stub so the
caller's org role can be varied, and prove: viewer POST to an existing
chat → 403 with no LLM call; viewer generate-title → 403; chat owner
and org admin (manager) still stream successfully; admin generate-title
still works; viewer GET of the same chat still returns 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With two or more owners in an org, an admin could demote an owner to
member (or remove them outright), because the membership mutations
checked the ACTOR's rank but never compared it to the TARGET's.

WHY THIS MATTERS

The role ladder is owner > admin > member. updateMember and
removeMember guarded three things:

  - the actor can manage members at all (roleCanManage → owner/admin),
  - "only an owner may GRANT the owner role" (no self-escalation),
  - last-owner protection (never demote/remove the sole owner).

None of those look at who the target IS. So in an org with owners A and
B plus admin C, C could call updateMember(target=A, role="member"):
C passes roleCanManage, "member" is not "owner" so the grant check does
not fire, and B still counts as an owner so last-owner passes. Result:
an admin deposes an owner — and with both owners demoted one at a time,
an admin becomes the effective top of the org without ever holding the
owner role. That inverts the hierarchy the ladder is supposed to
encode.

WHAT IS AN ACTOR/TARGET RANK CHECK

Role-ladder authorization has two halves, and they are easy to conflate:

  1. Does the ACTOR's role permit this KIND of operation?
     ("admins may manage members")
  2. Does the ACTOR outrank-or-equal the TARGET of the operation?
     ("...but not members who outrank them")

Check (1) alone is enough for rank-neutral operations (creating a
team). Any operation aimed at another member also needs check (2),
otherwise every manager-tier role can act on the tier above it. The
existing "only an owner may grant owner" rule is the escalation half of
this idea; what was missing is the demotion half: only an owner may act
AGAINST an owner.

HOW THE FIX WORKS

Both mutations now fetch the target's role (they already did, for
last-owner counting) and add a rank guard before it:

    if (targetRole === "owner" && actorRole !== "owner")
        return { ok: false, kind: "forbidden" };

  - updateMember: an admin demoting an owner → forbidden, regardless of
    how many owners exist. Owner-on-owner changes still work.
  - removeMember: an admin removing an owner → forbidden. An owner
    leaving on their own is unaffected: self-leave implies
    actorRole === targetRole === "owner", so the guard passes and the
    existing last-owner protection still has the final say.

Since owner/admin/member is a three-rung ladder, this single condition
IS the full outrank-or-equal rule: owners outrank everyone, admins may
act on admins/members, and plain members cannot reach these functions
at all (roleCanManage already rejects them).

Tests (orgs.test.ts) seed a two-owner org so last-owner protection is
provably not what stops the attack: admin-demotes-owner → forbidden,
admin-removes-owner → forbidden, owner-demotes-owner still ok,
owner-self-leave with a second owner still ok, and the existing
last-owner cases stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adding an org or team member by email used the Supabase admin API's
listUsers({ perPage: 1000 }) and scanned the page in JavaScript. Any
user beyond the first 1000 accounts silently resolved to null, so the
route answered "No user with that email" for a user who exists.

WHY THIS MATTERS

List-and-scan lookups against an admin API are a scalability trap that
looks fine in every dev and demo environment:

  - CORRECTNESS decays with growth. listUsers is paginated; a single
    perPage: 1000 call is only "everyone" while the instance has fewer
    than 1000 accounts. The day it crosses that line, membership adds
    start failing for exactly the newest users — with a misleading 404
    and no error anywhere in the logs.
  - COST grows linearly. Even while it still works, resolving ONE email
    means transferring up to 1000 user records and comparing each in
    process, on every add-member call — O(n) network and CPU for what
    an indexed table answers in O(log n).
  - The admin auth API is a management surface, not a query engine. It
    has no "find by email" filter here, which is the hint that
    lookup-by-attribute belongs on a queryable table with an index.

The comment above the helper claimed it "mirrors the lookup pattern in
routes/projects.ts /people" — but that route actually uses the
user_profiles-based helpers in lib/userLookup.ts, not the admin API.
The codebase already had the right tool; this route just didn't use it.

WHAT THE INDEXED LOOKUP IS

lib/userLookup.ts maintains lookups over the user_profiles table, which
stores each user's normalized (lowercased, trimmed) email and is kept
in sync on auth events (syncProfileEmail). findProfileUserByEmail is a
single indexed query:

    const { data } = await db
        .from("user_profiles")
        .select("user_id, email, display_name")
        .eq("email", normalized)
        .maybeSingle();

One row travels over the wire regardless of whether the instance has
100 users or 10 million, and "not found" is an honest answer instead of
an artifact of pagination.

HOW THE FIX WORKS

routes/orgs.ts's resolveUserIdByEmail now delegates to that helper:

    const user = await findProfileUserByEmail(db, email);
    return user?.id ?? null;

The contract is unchanged — null when no such user — so both callers
(POST /orgs/:orgId/members and POST /orgs/:orgId/teams/:teamId/members)
keep their existing 404 behavior for genuinely unknown emails, and the
stale comment is corrected. This was also the last listUsers scan in
the backend. Email normalization (trim + lowercase) lives inside the
helper, matching the semantics the old loop implemented by hand.

findProfileUserByEmail is covered by unit tests
(lib/__tests__/userLookup.test.ts: found via normalization, not-found,
and blank-input cases); the org routes have no route-level test file,
so the two-line delegation rides on that existing coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…API actually calls

WHY THIS MATTERS

PR migration 20260717_03 added the new org-membership visibility branch
("you can see rows tagged with an org you belong to") to
get_tabular_reviews_overview — but only to its legacy 3-argument
overload. The API never calls that overload. GET /tabular-review builds
nine named arguments (p_user_id, p_user_email, p_project_id, p_scope,
p_limit, p_offset, p_search_term, p_sort_key, p_sort_direction) in
lib/tabularReviewsOverview.ts, so PostgREST resolves the 9-argument
overload from 20260726_01 — which never got the org branch. Result: on
any install upgraded via migrations, org-shared reviews were readable
through the detail endpoints (access.ts allows them) yet invisible in
every list view. Fresh installs, which bootstrap from schema.sql, were
unaffected because schema.sql's 9-argument version already carries the
org branch — so fresh and migrated databases silently diverged, the
worst kind of bug: nothing errors, some deployments just show less data.

WHAT IS FUNCTION OVERLOADING IN POSTGRES

Postgres identifies a function by name AND argument types.
get_tabular_reviews_overview(text, text, text) and
get_tabular_reviews_overview(text, text, text, text, integer, integer,
text, text, text) are two completely independent functions that happen
to share a name. CREATE OR REPLACE matches on the full signature, so
replacing one overload never touches the other:

  create or replace function f(a text) ...           -- replaces f(text)
  create or replace function f(a text, b int) ...    -- separate function!

PostgREST resolves an RPC call to the overload whose named parameters
match the JSON body it received. A call with nine named keys can only
ever hit the 9-argument overload. That is why patching the 3-argument
overload in 20260717_03 was a no-op for the API.

HOW THE FIX WORKS

This migration re-declares both overloads exactly as backend/schema.sql
already defines them (the bodies are copied verbatim, not rewritten):

1. The 9-argument overload gains the two org-membership arms:
   - accessible_projects: projects whose org_id is in an org the caller
     belongs to (EXISTS against org_members) — so in-project reviews of
     org colleagues become visible;
   - visible_reviews: org-tagged reviews owned by someone else become
     visible in the global list (p_project_id is null).
2. The 3-argument overload becomes the thin wrapper schema.sql uses —
   it simply delegates to the 9-argument version with scope 'all' and an
   effectively unlimited page — replacing 20260717_03's divergent
   full-body copy. From now on there is a single source of truth for the
   visibility predicate, and this file sorts after every earlier
   overview migration, so replaying migrations from scratch also ends in
   the correct state (previously 20260726_01 sorted after 20260717_03
   and wiped the org branch on replay).

Signatures do not change, so CREATE OR REPLACE suffices — no DROP
FUNCTION, and the migration is safe to re-run. schema.sql needs no
change: it was already correct; this converges migrated installs onto
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS

get_tabular_review_ids_overview backs GET /tabular-review/ids, the bulk
"select all matching" action: it returns id + owner for every review the
caller can see, so the client can select the whole filtered set without
fetching full payloads. For performance it does NOT delegate to
get_tabular_reviews_overview — it carries its own copy of the visibility
predicate, and its own migration (20260727_01) warns in its header:

  "If the access/visibility rules in get_tabular_reviews_overview's
   visible_reviews CTE ever change, mirror the change here too."

The organizations work changed exactly those rules — it added a third
visibility branch, "rows tagged with an org the caller belongs to",
alongside "row owner" and "shared_with email" — but this RPC was never
mirrored, neither in a migration nor in schema.sql. The user-visible
symptom is nasty because it is partial: an org member SEES a colleague's
org-shared reviews in the list, but "select all matching" silently
skips them, so bulk actions run over fewer rows than the visible
selection implies. Nothing errors; rows just go missing.

HOW THE FIX WORKS

Add the same two org-membership arms the paginated overview uses
(compare its 9-argument definition in schema.sql):

1. accessible_projects gains an EXISTS against org_members on
   p.org_id — reviews living in an org colleague's project become
   visible, exactly like email-shared projects already were:

     or (
       p.org_id is not null
       and p.user_id <> p_user_id
       and exists (
         select 1 from public.org_members m
         where m.org_id = p.org_id and m.user_id::text = p_user_id
       )
     )

2. The row filter gains the matching arm for org-tagged reviews in the
   global list (p_project_id is null), keyed on tr.org_id.

The change is made in BOTH places that define this function — edited
in place in backend/schema.sql (fresh installs bootstrap from it) and
as new migration 20260805_02 (upgrades existing installs) — with
byte-identical function bodies, so the two install paths cannot
diverge. org_members.user_id is a uuid FK to auth.users while content
tables store user_id as text, hence the ::text casts. Org membership
grants visibility only; user_id still identifies the owner.

The signature is unchanged, so the migration is CREATE OR REPLACE only
and safe to re-run.

TESTS

The org-visibility integration suite (tabularPagination.supabase.test.ts)
now seeds two real auth users in one org — the FK to auth.users means
random UUIDs won't do — makes one a plain "member", and asserts the
member sees the colleague's in-project and standalone org reviews via
the ids RPC, plus a lockstep assertion that the ids RPC returns exactly
the set the paginated overview shows, which is the drift this bug class
is about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…actor

WHY THIS MATTERS
The manager-gate test for POST /tabular-review/:reviewId/clear-cells was
written when the endpoint keyed cell resets on document_ids. After
rebasing onto main it hit the 400 validation branch ("row_ids is
required") before ever reaching the 403 role check it exists to prove,
so the RBAC gate went untested.

WHAT IS THE LOGICAL-ROWS REFACTOR
Main's folder-grouped tabular review work (Open-Legal-Products#274) reshaped reviews around
logical review rows: a row can represent a folder of documents, not just
one document, so cell-level operations now address rows. clear-cells
accordingly takes row_ids instead of document_ids.

HOW THE FIX WORKS
The test now sends { row_ids: ["row-1"] }, a valid payload under the
current API, so the request passes validation and exercises the intended
assertion: an org editor without structure.manage receives 403 "Only a
review manager can clear cells". No production code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant